import importlib.resources as pkg_resources
import json
import os
import threading
from typing import Any, Dict, List, Optional, Type, Union

from loguru import logger
from sqlalchemy import func, or_
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session

from .. import defaults
from ..__version__ import __version__ as package_version
from ..database.models import Setting, SettingType
from ..web.models.settings import (
    AppSetting,
    BaseSetting,
    LLMSetting,
    ReportSetting,
    SearchSetting,
)
from .base import ISettingsManager
from .env_registry import registry as env_registry


def parse_boolean(value: Any) -> bool:
    """
    Convert various representations to boolean using HTML checkbox semantics.

    This function handles form values, JSON booleans, and environment variables,
    ensuring consistent behavior across client and server.

    **HTML Checkbox Semantics** (INTENTIONAL DESIGN):
    - **Any value present (except explicit false) = checked = True**
    - This matches standard HTML form behavior where checkbox presence indicates checked state
    - In HTML forms, checkboxes send a value when checked, nothing when unchecked

    **Examples**:
        parse_boolean("on")         # True  - standard HTML checkbox value
        parse_boolean("true")       # True  - explicit true
        parse_boolean("1")          # True  - numeric true
        parse_boolean("enabled")    # True  - any non-empty string
        parse_boolean("disabled")   # True  - INTENTIONAL: any string = checkbox was checked!
        parse_boolean("custom")     # True  - custom checkbox value

        parse_boolean("false")      # False - explicit false
        parse_boolean("off")        # False - explicit false
        parse_boolean("0")          # False - explicit false
        parse_boolean("")           # False - empty string = unchecked
        parse_boolean(None)         # False - missing = unchecked

    **Why "disabled" returns True**:
    This is NOT a bug! If a checkbox sends the value "disabled", it means the checkbox
    was checked (present in form data). The actual string content doesn't matter for
    HTML checkboxes - only presence vs absence matters.

    Args:
        value: Value to convert to boolean. Accepts strings, booleans, or None.

    Returns:
        bool: True for truthy values (any non-empty string except explicit false);
              False for falsy values ('off', 'false', '0', '', 'no', False, None)

    Note:
        This function implements HTML form semantics, NOT generic boolean parsing.
        See tests/settings/test_boolean_parsing.py for comprehensive test coverage.
    """
    # Constants for boolean value parsing
    FALSY_VALUES = ("off", "false", "0", "", "no")

    # Handle already-boolean values
    if isinstance(value, bool):
        return value

    # Handle None (missing values)
    if value is None:
        return False

    # Handle string values
    if isinstance(value, str):
        value_lower = value.lower().strip()
        # Explicitly falsy values (empty string, false-like values)
        if value_lower in FALSY_VALUES:
            return False
        # Any other non-empty string = True (HTML checkbox semantics)
        return True

    # For other types (numbers, lists, etc.), use Python's bool conversion
    return bool(value)


def _parse_number(x):
    """Parse number, returning int if it's a whole number, otherwise float."""
    f = float(x)
    if f.is_integer():
        return int(f)
    return f


_UI_ELEMENT_TO_SETTING_TYPE = {
    "text": str,
    # SQLAlchemy should already handle JSON parsing.
    "json": lambda x: x,
    "password": str,
    "select": str,
    "number": _parse_number,
    "range": _parse_number,  # Same behavior as number for consistency
    "checkbox": parse_boolean,
}


def get_typed_setting_value(
    key: str,
    value: Any,
    ui_element: str,
    default: Any = None,
    check_env: bool = True,
) -> Any:
    """
    Extracts the value for a particular setting, ensuring that it has the
    correct type.

    Args:
        key: The setting key.
        value: The setting value from the database.
        ui_element: The setting UI element ID.
        default: Default value to return if the value of the setting is
            invalid.
        check_env: If true, it will check the environment variable for
            this setting before reading from the DB.

    Returns:
        The value of the setting.

    """
    if value is None:
        # Value was not in the database.
        return default

    setting_type = _UI_ELEMENT_TO_SETTING_TYPE.get(ui_element, None)
    if setting_type is None:
        logger.warning(
            "Got unknown type {} for setting {}, returning default value.",
            ui_element,
            key,
        )
        return default

    # Check environment variable first, then database.
    if check_env:
        env_value = check_env_setting(key)
        if env_value is not None:
            try:
                # Special handling for boolean values
                if setting_type is bool:
                    # Convert string to boolean properly
                    return env_value.lower() in ("true", "1", "yes", "on")
                return setting_type(env_value)
            except ValueError:
                logger.warning(
                    "Setting {} has invalid value {}. Falling back to DB.",
                    key,
                    env_value,
                )

    # If environment variable does not exist, read from the database.
    try:
        return setting_type(value)
    except (ValueError, TypeError):
        logger.warning(
            "Setting {} has invalid value {}. Returning default.",
            key,
            value,
        )
        return default


def check_env_setting(key: str) -> str | None:
    """
    Checks environment variables for a particular setting.

    Args:
        key: The database key for the setting.

    Returns:
        The setting from the environment variables, or None if the variable
        is not set.

    """
    env_variable_name = f"LDR_{'_'.join(key.split('.')).upper()}"
    env_value = os.getenv(env_variable_name)
    if env_value is not None:
        logger.debug(f"Overriding {key} setting from environment variable.")
    return env_value


class SettingsManager(ISettingsManager):
    """
    Manager for handling application settings with database storage and file fallback.
    Provides methods to get and set settings, with the ability to override settings in memory.
    """

    def __init__(self, db_session: Optional[Session] = None):
        """
        Initialize the settings manager

        Args:
            db_session: SQLAlchemy session for database operations
        """
        self.db_session = db_session
        self.db_first = True  # Always prioritize DB settings

        # Store the thread ID this instance was created in
        self._creation_thread_id = threading.get_ident()

        # Initialize settings lock as None - will be checked lazily
        self.__settings_locked = None

        # Auto-initialize settings if database is empty
        if self.db_session:
            self._ensure_settings_initialized()

    def _ensure_settings_initialized(self):
        """Ensure settings are initialized in the database."""
        # Check if we have any settings at all
        from ..database.models import Setting

        settings_count = self.db_session.query(Setting).count()

        if settings_count == 0:
            logger.info("No settings found in database, loading defaults")
            self.load_from_defaults_file(commit=True)
            logger.info("Default settings loaded successfully")

    def _check_thread_safety(self):
        """Check if this instance is being used in the same thread it was created in."""
        current_thread_id = threading.get_ident()
        if self.db_session and current_thread_id != self._creation_thread_id:
            raise RuntimeError(
                f"SettingsManager instance created in thread {self._creation_thread_id} "
                f"is being used in thread {current_thread_id}. This is not thread-safe! "
                f"Create a new SettingsManager instance within the current thread context."
            )

    @property
    def settings_locked(self) -> bool:
        """Check if settings are locked (lazy evaluation)."""
        if self.__settings_locked is None:
            try:
                self.__settings_locked = self.get_setting(
                    "app.lock_settings", False
                )
                if self.settings_locked:
                    logger.info(
                        "Settings are locked. Disabling all settings changes."
                    )
            except Exception:
                # If we can't check, assume not locked
                self.__settings_locked = False
        return self.__settings_locked

    @property
    def default_settings(self) -> Dict[str, Any]:
        """
        Returns:
            The default settings, loaded from JSON.

        """
        default_settings = pkg_resources.read_text(
            defaults, "default_settings.json"
        )
        return json.loads(default_settings)

    def __get_typed_setting_value(
        self,
        setting: Type[Setting],
        default: Any = None,
        check_env: bool = True,
    ) -> Any:
        """
        Extracts the value for a particular setting, ensuring that it has the
        correct type.

        Args:
            setting: The setting to get the value for.
            default: Default value to return if the value of the setting is
                invalid.
            check_env: If true, it will check the environment variable for
                this setting before reading from the DB.

        Returns:
            The value of the setting.

        """
        return get_typed_setting_value(
            setting.key,
            setting.value,
            setting.ui_element,
            default=default,
            check_env=check_env,
        )

    def __query_settings(self, key: str | None = None) -> List[Type[Setting]]:
        """
        Abstraction for querying settings that also transparently handles
        reading the default settings file if the DB is not enabled.

        Args:
            key: The key to read. If None, it will read everything.

        Returns:
            The settings it queried.

        """
        if self.db_session:
            self._check_thread_safety()
            query = self.db_session.query(Setting)
            if key is not None:
                # This will find exact matches and any subkeys.
                query = query.filter(
                    or_(
                        Setting.key == key,
                        Setting.key.startswith(f"{key}."),
                    )
                )
            return query.all()

        else:
            logger.debug(
                "DB is disabled, reading setting '{}' from defaults file.", key
            )

            settings = []
            for candidate_key, setting in self.default_settings.items():
                if key is None or (
                    candidate_key == key or candidate_key.startswith(f"{key}.")
                ):
                    settings.append(Setting(key=candidate_key, **setting))

            return settings

    def get_setting(
        self, key: str, default: Any = None, check_env: bool = True
    ) -> Any:
        """
        Get a setting value

        Args:
            key: Setting key
            default: Default value if setting is not found
            check_env: If true, it will check the environment variable for
                this setting before reading from the DB.

        Returns:
            Setting value or default if not found
        """

        # First check if this is an env-only setting
        if env_registry.is_env_only(key):
            return env_registry.get(key, default)

        # If using database first approach and session available, check database
        try:
            settings = self.__query_settings(key)
            if len(settings) == 1:
                # This is a bottom-level key.
                result = self.__get_typed_setting_value(
                    settings[0], default, check_env
                )
                # Cache the result
                return result
            elif len(settings) > 1:
                # This is a higher-level key.
                settings_map = {}
                for setting in settings:
                    output_key = setting.key.removeprefix(f"{key}.")
                    settings_map[output_key] = self.__get_typed_setting_value(
                        setting, default, check_env
                    )
                return settings_map
        except SQLAlchemyError as e:
            logger.exception(
                f"Error retrieving setting {key} from database: {e}"
            )

        # Return default if not found
        return default

    def set_setting(self, key: str, value: Any, commit: bool = True) -> bool:
        """
        Set a setting value

        Args:
            key: Setting key
            value: Setting value
            commit: Whether to commit the change

        Returns:
            True if successful, False otherwise
        """
        if not self.db_session:
            logger.error(
                "Cannot edit setting {} because no DB was provided.", key
            )
            return False
        if self.settings_locked:
            logger.error("Cannot edit setting {} because they are locked.", key)
            return False

        # Always update database if available
        try:
            self._check_thread_safety()
            setting = (
                self.db_session.query(Setting)
                .filter(Setting.key == key)
                .first()
            )
            if setting:
                if not setting.editable:
                    logger.error(
                        "Cannot change setting '{}' because it "
                        "is marked as non-editable.",
                        key,
                    )
                    return False

                setting.value = value
                setting.updated_at = (
                    func.now()
                )  # Explicitly set the current timestamp
            else:
                # Determine setting type from key
                setting_type = SettingType.APP
                if key.startswith("llm."):
                    setting_type = SettingType.LLM
                elif key.startswith("search."):
                    setting_type = SettingType.SEARCH
                elif key.startswith("report."):
                    setting_type = SettingType.REPORT
                elif key.startswith("database."):
                    setting_type = SettingType.DATABASE

                # Create a new setting
                new_setting = Setting(
                    key=key,
                    value=value,
                    type=setting_type,
                    name=key.split(".")[-1].replace("_", " ").title(),
                    ui_element="text",
                    description=f"Setting for {key}",
                )
                self.db_session.add(new_setting)

            if commit:
                self.db_session.commit()
                # Emit WebSocket event for settings change
                self._emit_settings_changed([key])

            return True
        except SQLAlchemyError:
            logger.exception(f"Error setting value for key: {key}")
            self.db_session.rollback()
            return False

    def clear_cache(self):
        """Clear the settings cache."""
        logger.debug("Settings cache cleared")

    def get_all_settings(self, bypass_cache: bool = False) -> Dict[str, Any]:
        """
        Get all settings, merging defaults with database values.

        This ensures that new settings added to defaults.json automatically
        appear in the UI without requiring a database reset.

        Args:
            bypass_cache: If True, bypass the cache and read directly from database

        Returns:
            Dictionary of all settings
        """
        result = {}

        # Start with defaults so new settings are always included
        for key, default_setting in self.default_settings.items():
            result[key] = dict(default_setting)

        # Override with database settings if available
        try:
            for setting in self.__query_settings():
                # Handle type field - it might be a string or an enum
                setting_type = setting.type
                if hasattr(setting_type, "name"):
                    setting_type = setting_type.name

                # Log if this is a custom setting not in defaults
                if setting.key not in self.default_settings:
                    logger.debug(
                        f"Database contains custom setting not in "
                        f"defaults: {setting.key} (type={setting_type}, "
                        f"category={setting.category})"
                    )

                # Override default with database value
                result[setting.key] = dict(
                    value=setting.value,
                    type=setting_type,
                    name=setting.name,
                    description=setting.description,
                    category=setting.category,
                    ui_element=setting.ui_element,
                    options=setting.options,
                    min_value=setting.min_value,
                    max_value=setting.max_value,
                    step=setting.step,
                    visible=setting.visible,
                    editable=False
                    if self.settings_locked
                    else setting.editable,
                )

                # Override from the environment variables if needed.
                env_value = check_env_setting(setting.key)
                if env_value is not None:
                    result[setting.key]["value"] = env_value
                    # Mark it as non-editable, because changes to the DB
                    # value have no effect as long as the environment
                    # variable is set.
                    result[setting.key]["editable"] = False
        except SQLAlchemyError as e:
            logger.exception(
                f"Error retrieving all settings from database: {e}"
            )

        return result

    def get_settings_snapshot(self) -> Dict[str, Any]:
        """
        Get a simplified settings snapshot with just key-value pairs.
        This is useful for passing settings to background threads or storing in metadata.

        Returns:
            Dictionary with setting keys mapped to their values
        """
        all_settings = self.get_all_settings()
        settings_snapshot = {}

        for key, setting in all_settings.items():
            if isinstance(setting, dict) and "value" in setting:
                settings_snapshot[key] = setting["value"]
            else:
                settings_snapshot[key] = setting

        return settings_snapshot

    def create_or_update_setting(
        self, setting: Union[BaseSetting, Dict[str, Any]], commit: bool = True
    ) -> Optional[Setting]:
        """
        Create or update a setting

        Args:
            setting: Setting object or dictionary
            commit: Whether to commit the change

        Returns:
            The created or updated Setting model, or None if failed
        """
        if not self.db_session:
            logger.warning(
                "No database session available, cannot create/update setting"
            )
            return None
        if self.settings_locked:
            logger.error("Cannot edit settings because they are locked.")
            return None

        # Convert dict to BaseSetting if needed
        if isinstance(setting, dict):
            # Determine type from key if not specified
            if "type" not in setting and "key" in setting:
                key = setting["key"]
                if key.startswith("llm."):
                    setting_obj = LLMSetting(**setting)
                elif key.startswith("search."):
                    setting_obj = SearchSetting(**setting)
                elif key.startswith("report."):
                    setting_obj = ReportSetting(**setting)
                else:
                    setting_obj = AppSetting(**setting)
            else:
                # Use generic BaseSetting
                setting_obj = BaseSetting(**setting)
        else:
            setting_obj = setting

        try:
            # Check if setting exists
            db_setting = (
                self.db_session.query(Setting)
                .filter(Setting.key == setting_obj.key)
                .first()
            )

            if db_setting:
                # Update existing setting
                if setting.editable:
                    logger.error(
                        "Cannot change setting '{}' because it "
                        "is marked as non-editable.",
                        setting["key"],
                    )
                    return None

                db_setting.value = setting_obj.value
                db_setting.name = setting_obj.name
                db_setting.description = setting_obj.description
                db_setting.category = setting_obj.category
                db_setting.ui_element = setting_obj.ui_element
                db_setting.options = setting_obj.options
                db_setting.min_value = setting_obj.min_value
                db_setting.max_value = setting_obj.max_value
                db_setting.step = setting_obj.step
                db_setting.visible = setting_obj.visible
                db_setting.editable = setting_obj.editable
                db_setting.updated_at = (
                    func.now()
                )  # Explicitly set the current timestamp
            else:
                # Create new setting
                db_setting = Setting(
                    key=setting_obj.key,
                    value=setting_obj.value,
                    type=SettingType[setting_obj.type.upper()],
                    name=setting_obj.name,
                    description=setting_obj.description,
                    category=setting_obj.category,
                    ui_element=setting_obj.ui_element,
                    options=setting_obj.options,
                    min_value=setting_obj.min_value,
                    max_value=setting_obj.max_value,
                    step=setting_obj.step,
                    visible=setting_obj.visible,
                    editable=setting_obj.editable,
                )
                self.db_session.add(db_setting)

            if commit:
                self.db_session.commit()
                # Emit WebSocket event for settings change
                self._emit_settings_changed([setting_obj.key])

            return db_setting

        except SQLAlchemyError as e:
            logger.exception(
                f"Error creating/updating setting {setting_obj.key}: {e}"
            )
            self.db_session.rollback()
            return None

    def delete_setting(self, key: str, commit: bool = True) -> bool:
        """
        Delete a setting

        Args:
            key: Setting key
            commit: Whether to commit the change

        Returns:
            True if successful, False otherwise
        """
        if not self.db_session:
            logger.warning(
                "No database session available, cannot delete setting"
            )
            return False

        try:
            # Remove from database
            result = (
                self.db_session.query(Setting)
                .filter(Setting.key == key)
                .delete()
            )

            if commit:
                self.db_session.commit()

            return result > 0
        except SQLAlchemyError:
            logger.exception("Error deleting setting")
            self.db_session.rollback()
            return False

    def load_from_defaults_file(
        self, commit: bool = True, **kwargs: Any
    ) -> None:
        """
        Import settings from the defaults settings file.

        Args:
            commit: Whether to commit changes to database
            **kwargs: Will be passed to `import_settings`.

        """
        self.import_settings(self.default_settings, commit=commit, **kwargs)

    def db_version_matches_package(self) -> bool:
        """
        Returns:
            True if the version saved in the DB matches the package version.

        """
        db_version = self.get_setting("app.version")
        logger.debug(
            f"App version saved in DB is {db_version}, have package "
            f"settings from version {package_version}."
        )

        return db_version == package_version

    def update_db_version(self) -> None:
        """
        Updates the version saved in the DB based on the package version.

        """
        logger.debug(f"Updating saved DB version to {package_version}.")

        self.delete_setting("app.version", commit=False)
        version = Setting(
            key="app.version",
            value=package_version,
            description="Version of the app this database is associated with.",
            editable=False,
            name="App Version",
            type=SettingType.APP,
            ui_element="text",
            visible=False,
        )

        self.db_session.add(version)
        self.db_session.commit()

    def import_settings(
        self,
        settings_data: Dict[str, Any],
        commit: bool = True,
        overwrite: bool = True,
        delete_extra: bool = False,
    ) -> None:
        """
        Import settings directly from the export format. This can be used to
        re-import settings that have been exported with `get_all_settings()`.

        Args:
            settings_data: The raw settings data to import.
            commit: Whether to commit the DB after loading the settings.
            overwrite: If true, it will overwrite the value of settings that
                are already in the database.
            delete_extra: If true, it will delete any settings that are in
                the database but don't have a corresponding entry in
                `settings_data`.

        """
        logger.debug(f"Importing {len(settings_data)} settings")

        for key, setting_values in settings_data.items():
            if not overwrite:
                existing_value = self.get_setting(key)
                if existing_value is not None:
                    # Preserve the value from this setting.
                    setting_values["value"] = existing_value

            # Delete any existing setting so we can completely overwrite it.
            self.delete_setting(key, commit=False)

            setting = Setting(key=key, **setting_values)
            self.db_session.add(setting)

        if commit or delete_extra:
            self.db_session.commit()
            logger.info(f"Successfully imported {len(settings_data)} settings")
            # Emit WebSocket event for all imported settings
            self._emit_settings_changed(list(settings_data.keys()))

        if delete_extra:
            all_settings = self.get_all_settings()
            for key in all_settings:
                if key not in settings_data:
                    logger.debug(f"Deleting extraneous setting: {key}")
                    self.delete_setting(key, commit=False)

    def _create_setting(self, key, value, setting_type):
        """Create a setting with appropriate metadata"""

        # Determine appropriate category
        category = None
        ui_element = "text"

        # Determine category based on key pattern
        if key.startswith("app."):
            category = "app_interface"
        elif key.startswith("llm."):
            if any(
                param in key
                for param in [
                    "temperature",
                    "max_tokens",
                    "n_batch",
                    "n_gpu_layers",
                ]
            ):
                category = "llm_parameters"
            else:
                category = "llm_general"
        elif key.startswith("search."):
            if any(
                param in key
                for param in ["iterations", "questions", "results", "region"]
            ):
                category = "search_parameters"
            else:
                category = "search_general"
        elif key.startswith("report."):
            category = "report_parameters"

        # Determine UI element type based on value
        if isinstance(value, bool):
            ui_element = "checkbox"
        elif isinstance(value, (int, float)) and not isinstance(value, bool):
            ui_element = "number"
        elif isinstance(value, (dict, list)):
            ui_element = "textarea"

        # Build setting object
        setting_dict = {
            "key": key,
            "value": value,
            "type": setting_type.value.lower(),
            "name": key.split(".")[-1].replace("_", " ").title(),
            "description": f"Setting for {key}",
            "category": category,
            "ui_element": ui_element,
        }

        # Create the setting in the database
        self.create_or_update_setting(setting_dict, commit=False)

    def _emit_settings_changed(self, changed_keys: list = None):
        """
        Emit WebSocket event when settings change

        Args:
            changed_keys: List of setting keys that changed
        """
        try:
            # Import here to avoid circular imports
            from ..web.services.socket_service import SocketIOService

            try:
                socket_service = SocketIOService()
            except ValueError:
                logger.debug(
                    "Not emitting socket event because server is not initialized."
                )
                return

            # Get the changed settings
            settings_data = {}
            if changed_keys:
                for key in changed_keys:
                    setting_value = self.get_setting(key)
                    if setting_value is not None:
                        settings_data[key] = {"value": setting_value}

            # Emit the settings change event
            from datetime import datetime, UTC

            socket_service.emit_socket_event(
                "settings_changed",
                {
                    "changed_keys": changed_keys or [],
                    "settings": settings_data,
                    "timestamp": datetime.now(UTC).isoformat(),
                },
            )

            logger.debug(
                f"Emitted settings_changed event for keys: {changed_keys}"
            )

        except Exception:
            logger.exception("Failed to emit settings change event")
            # Don't let WebSocket emission failures break settings saving

    @staticmethod
    def get_bootstrap_env_vars() -> Dict[str, str]:
        """
        Get environment variables that must be available before database access.
        These are critical for system initialization.

        Returns:
            Dict mapping env var names to their descriptions
        """
        # Get bootstrap vars from env registry
        return env_registry.get_bootstrap_vars()

    @staticmethod
    def is_bootstrap_env_var(env_var: str) -> bool:
        """
        Check if an environment variable is a bootstrap variable (needed before DB access).

        Args:
            env_var: Environment variable name

        Returns:
            True if this is a bootstrap variable
        """
        bootstrap_vars = SettingsManager.get_bootstrap_env_vars()
        return env_var in bootstrap_vars

    @staticmethod
    def is_env_only_setting(key: str) -> bool:
        """
        Check if a setting key is environment-only.

        Args:
            key: Setting key to check

        Returns:
            True if it's an env-only setting, False otherwise
        """
        return env_registry.is_env_only(key)

    @staticmethod
    def get_env_var_for_setting(setting_key: str) -> str:
        """
        Get the environment variable name for a given setting key.

        Args:
            setting_key: Setting key (e.g., "app.host")

        Returns:
            Environment variable name (e.g., "LDR_APP_HOST")
        """
        # Use the same logic as check_env_setting for consistency
        return f"LDR_{'_'.join(setting_key.split('.')).upper()}"

    @staticmethod
    def get_setting_key_for_env_var(env_var: str) -> Optional[str]:
        """
        Get the setting key for a given environment variable.

        Args:
            env_var: Environment variable name (e.g., "LDR_APP_HOST")

        Returns:
            Setting key (e.g., "app.host") or None if not a valid LDR env var
        """
        if not env_var.startswith("LDR_"):
            return None

        # Remove LDR_ prefix and convert to lowercase
        without_prefix = env_var[4:]
        parts = without_prefix.split("_")

        return ".".join(part.lower() for part in parts)
